page.tsx 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { VideoPlayer } from '@/components/video-player/VideoPlayer';
  8. import { Tool } from '@/components/video-player/AnnotationCanvas';
  9. import { formatTimecode } from '@/lib/format';
  10. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  11. const MAX_ANNOTATIONS = 10;
  12. const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
  13. PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
  14. CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
  15. APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
  16. REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
  17. };
  18. const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
  19. PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
  20. UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
  21. PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
  22. COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
  23. FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
  24. UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
  25. };
  26. export default function ReviewPage() {
  27. const params = useParams();
  28. const assetId = params.assetId as string;
  29. const { token, user } = useAuth();
  30. const router = useRouter();
  31. const [asset, setAsset] = useState<AssetWithComments | null>(null);
  32. const [comments, setComments] = useState<Comment[]>([]);
  33. const [loading, setLoading] = useState(true);
  34. const [currentTime, setCurrentTime] = useState(0);
  35. const [panelWidth, setPanelWidth] = useState(380);
  36. const [showApproval, setShowApproval] = useState(false);
  37. const [updatingStatus, setUpdatingStatus] = useState(false);
  38. const [newComment, setNewComment] = useState('');
  39. const [submitting, setSubmitting] = useState(false);
  40. const [replyTo, setReplyTo] = useState<Comment | null>(null);
  41. const [showResolved, setShowResolved] = useState(false);
  42. // Drawing state — lifted to page level
  43. const [drawMode, setDrawMode] = useState(false);
  44. const [drawTool, setDrawTool] = useState<Tool>('arrow');
  45. const [drawColor, setDrawColor] = useState('#ef4444');
  46. const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
  47. // The comment we're annotating (null = annotating the main video, not a specific comment)
  48. const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
  49. // Portrait / landscape detection
  50. const [isPortrait, setIsPortrait] = useState(false);
  51. // ── Side-by-side compare mode ────────────────────────────────────────────
  52. const [compareMode, setCompareMode] = useState(false);
  53. const [compareAsset, setCompareAsset] = useState<Asset | null>(null);
  54. const [showComparePicker, setShowComparePicker] = useState(false);
  55. const [projectAssets, setProjectAssets] = useState<Asset[]>([]);
  56. const [compareMismatch, setCompareMismatch] = useState<string | null>(null);
  57. const [compareComments, setCompareComments] = useState<Comment[]>([]);
  58. const [playing, setPlaying] = useState(false);
  59. // Toggle annotation + speech bubble visibility per video in compare mode
  60. const [showMainAnnotations, setShowMainAnnotations] = useState(true);
  61. const [showCompareAnnotations, setShowCompareAnnotations] = useState(true);
  62. // Video element ref so we can seek directly from comment timestamp clicks
  63. const mainVideoRef = useRef<HTMLVideoElement>(null);
  64. const handleCompareSelect = useCallback((compareAssetArg: Asset) => {
  65. setShowComparePicker(false);
  66. setCompareMismatch(null);
  67. const dur1 = asset?.duration ?? 0;
  68. const dur2 = compareAssetArg.duration ?? 0;
  69. const fps = asset?.fps ?? compareAssetArg.fps ?? 30;
  70. const diffFrames = Math.abs(dur1 - dur2) * fps;
  71. if (diffFrames > 5) {
  72. setCompareMismatch(
  73. `Videos differ by ${Math.round(diffFrames)} frames. Cannot compare — timing mismatch.`
  74. );
  75. // Show mismatch banner but don't enter compare mode
  76. setCompareAsset(compareAssetArg);
  77. setCompareMode(true);
  78. return;
  79. }
  80. setCompareAsset(compareAssetArg);
  81. setCompareMode(true);
  82. // Fetch compare asset's own comments for per-video annotations
  83. if (token) {
  84. commentsApi.list(token, compareAssetArg.id).then(({ comments: cc }) => {
  85. setCompareComments(cc);
  86. }).catch(() => setCompareComments([]));
  87. }
  88. }, [asset, token]);
  89. const handleExitCompare = useCallback(() => {
  90. setCompareMode(false);
  91. setCompareAsset(null);
  92. setCompareMismatch(null);
  93. setCompareComments([]);
  94. }, []);
  95. useEffect(() => {
  96. const mq = window.matchMedia('(orientation: portrait)');
  97. setIsPortrait(mq.matches);
  98. const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
  99. mq.addEventListener('change', handler);
  100. return () => mq.removeEventListener('change', handler);
  101. }, []);
  102. const isDraggingRef = useRef(false);
  103. const panelRef = useRef<HTMLDivElement>(null);
  104. const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
  105. // Ref to capture strokes for save callback (avoids closure stale value)
  106. const pendingStrokesRef = useRef<AnnotationData[]>([]);
  107. const annotatingCommentRef = useRef<Comment | null>(null);
  108. // Keep refs in sync with state
  109. useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
  110. useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
  111. const fps = asset?.fps ?? 30;
  112. // Derive the current user's project role
  113. const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
  114. const isProjectAdmin = currentUserRole === 'ADMIN';
  115. const isProjectOwner = asset?.project.ownerId === user?.id;
  116. const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
  117. // ── Poll for transcode progress ───────────────────────────────────────────
  118. const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
  119. const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  120. useEffect(() => {
  121. if (isTranscoding) {
  122. if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  123. return;
  124. }
  125. if (pollRef.current) return;
  126. pollRef.current = setInterval(async () => {
  127. if (!token) return;
  128. try {
  129. const { asset: updated } = await assetsApi.getStatus(token, assetId);
  130. setAsset(prev => prev ? { ...prev, ...updated } : prev);
  131. } catch {}
  132. }, 2000);
  133. return () => { if (pollRef.current) clearInterval(pollRef.current); };
  134. }, [token, assetId, isTranscoding]);
  135. // Load asset + comments
  136. const loadData = useCallback(async () => {
  137. if (!token) return;
  138. try {
  139. const [{ asset: a }, { comments: c }] = await Promise.all([
  140. assetsApi.get(token, assetId),
  141. commentsApi.list(token, assetId),
  142. ]);
  143. setAsset(a);
  144. setComments(c);
  145. } catch {
  146. router.push('/projects');
  147. } finally {
  148. setLoading(false);
  149. }
  150. }, [token, assetId, router]);
  151. useEffect(() => { loadData(); }, [loadData]);
  152. // ── Panel resize ─────────────────────────────────────────────────────────
  153. const handleMouseMove = useCallback((e: MouseEvent) => {
  154. if (!isDraggingRef.current || !resizeStartRef.current) return;
  155. const dx = e.clientX - resizeStartRef.current.x;
  156. setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w + dx)));
  157. }, []);
  158. const handleMouseUp = useCallback(() => {
  159. isDraggingRef.current = false;
  160. resizeStartRef.current = null;
  161. document.body.style.userSelect = '';
  162. document.body.style.cursor = '';
  163. }, []);
  164. useEffect(() => {
  165. window.addEventListener('mousemove', handleMouseMove);
  166. window.addEventListener('mouseup', handleMouseUp);
  167. return () => {
  168. window.removeEventListener('mousemove', handleMouseMove);
  169. window.removeEventListener('mouseup', handleMouseUp);
  170. };
  171. }, [handleMouseMove, handleMouseUp]);
  172. const handleResizeStart = (e: React.MouseEvent) => {
  173. e.preventDefault();
  174. isDraggingRef.current = true;
  175. resizeStartRef.current = { x: e.clientX, w: panelWidth };
  176. document.body.style.userSelect = 'none';
  177. document.body.style.cursor = 'col-resize';
  178. };
  179. // ── Comment actions ───────────────────────────────────────────────────────
  180. const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
  181. if (!token || !content.trim()) return;
  182. setSubmitting(true);
  183. try {
  184. const { comment } = await commentsApi.create(token, assetId, {
  185. content: content.trim(),
  186. timestamp,
  187. annotations,
  188. parentId: replyTo?.id,
  189. });
  190. if (replyTo) {
  191. setComments(prev => prev.map(c =>
  192. c.id === replyTo.id
  193. ? { ...c, replies: [...(c.replies ?? []), comment] }
  194. : c
  195. ));
  196. } else {
  197. setComments(prev => [...prev, comment]);
  198. }
  199. setNewComment('');
  200. setPendingStrokes([]);
  201. setReplyTo(null);
  202. } catch (err) {
  203. alert(err instanceof Error ? err.message : 'Failed to add comment');
  204. } finally {
  205. setSubmitting(false);
  206. }
  207. };
  208. const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
  209. if (!token) return;
  210. try {
  211. const { comment } = await commentsApi.resolve(token, commentId, action);
  212. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  213. } catch (err) {
  214. alert(err instanceof Error ? err.message : 'Failed to update comment');
  215. }
  216. };
  217. const handleRequestResolve = async (commentId: string) => {
  218. if (!token) return;
  219. try {
  220. const { comment } = await commentsApi.requestResolve(token, commentId);
  221. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  222. } catch (err) {
  223. alert(err instanceof Error ? err.message : 'Failed to request resolve');
  224. }
  225. };
  226. const handleDeleteComment = async (commentId: string) => {
  227. if (!token) return;
  228. // Soft delete — just mark hidden, owner can restore
  229. try {
  230. await commentsApi.delete(token, commentId);
  231. setComments(prev => prev.map(c =>
  232. c.id === commentId ? { ...c, deleted: true } : c
  233. ));
  234. } catch {
  235. alert('Failed to hide comment');
  236. }
  237. };
  238. const handleRestoreComment = async (commentId: string) => {
  239. if (!token) return;
  240. try {
  241. const { comment } = await commentsApi.restoreComment(token, commentId);
  242. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  243. } catch {
  244. alert('Failed to restore comment');
  245. }
  246. };
  247. // ── Annotation actions ─────────────────────────────────────────────────────
  248. // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
  249. const handleAddAnnotationClick = (comment: Comment) => {
  250. const existingCount = comment.annotations?.length ?? 0;
  251. if (existingCount >= MAX_ANNOTATIONS) {
  252. alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
  253. return;
  254. }
  255. setPendingStrokes([]);
  256. setAnnotatingComment(comment);
  257. setDrawMode(true);
  258. };
  259. // Each completed stroke is added to pendingStrokes
  260. const handleStrokeComplete = (stroke: AnnotationData) => {
  261. setPendingStrokes(prev => {
  262. const next = [...prev, stroke];
  263. if (next.length >= MAX_ANNOTATIONS) {
  264. setDrawMode(false);
  265. }
  266. return next;
  267. });
  268. };
  269. // Save pending strokes as annotation on the parent comment (no separate reply)
  270. const handleSaveAnnotations = () => {
  271. const strokes = pendingStrokesRef.current;
  272. const parent = annotatingCommentRef.current;
  273. if (!token || !parent || strokes.length === 0) {
  274. setPendingStrokes([]);
  275. setDrawMode(false);
  276. setAnnotatingComment(null);
  277. return;
  278. }
  279. setSubmitting(true);
  280. setPendingStrokes([]);
  281. setDrawMode(false);
  282. setAnnotatingComment(null);
  283. commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
  284. setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
  285. }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
  286. };
  287. // Discard pending strokes
  288. const handleUndoAnnotations = () => {
  289. setPendingStrokes([]);
  290. setDrawMode(false);
  291. setAnnotatingComment(null);
  292. };
  293. // Delete a single annotation from a comment (owner only)
  294. const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
  295. if (!token) return;
  296. try {
  297. const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
  298. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  299. } catch {
  300. alert('Failed to delete annotation');
  301. }
  302. };
  303. const handleStatusUpdate = async (status: string) => {
  304. if (!token) return;
  305. setUpdatingStatus(true);
  306. try {
  307. const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
  308. setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
  309. setShowApproval(false);
  310. } catch {
  311. alert('Failed to update status');
  312. } finally {
  313. setUpdatingStatus(false);
  314. }
  315. };
  316. const handleTimeUpdate = useCallback((time: number) => {
  317. setCurrentTime(time);
  318. }, []);
  319. const handleCommentSeek = useCallback((comment: Comment) => {
  320. const time = comment.timestamp ?? 0;
  321. setCurrentTime(time);
  322. if (mainVideoRef.current) {
  323. mainVideoRef.current.pause();
  324. mainVideoRef.current.currentTime = time;
  325. }
  326. }, []);
  327. const status = asset?.status ?? 'PENDING_REVIEW';
  328. const statusCfg = STATUS_CONFIG[status];
  329. const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
  330. const videoUrl = asset?.hlsPath
  331. ? `${API_BASE}/uploads${asset.hlsPath}`
  332. : asset
  333. ? `${API_BASE}/uploads/${asset.filePath}`
  334. : '';
  335. const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
  336. const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
  337. // Seek to previous/next comment (defined here so they can reference visibleComments)
  338. const handlePrevComment = useCallback(() => {
  339. const ts = visibleComments
  340. .filter(c => c.timestamp != null)
  341. .map(c => c.timestamp as number)
  342. .sort((a, b) => b - a);
  343. const prev = ts.find(t => t < currentTime - 0.3);
  344. if (prev !== undefined) handleCommentSeek({ timestamp: prev } as Comment);
  345. }, [visibleComments, currentTime, handleCommentSeek]);
  346. const handleNextComment = useCallback(() => {
  347. const ts = visibleComments
  348. .filter(c => c.timestamp != null)
  349. .map(c => c.timestamp as number)
  350. .sort((a, b) => a - b);
  351. const next = ts.find(t => t > currentTime + 0.3);
  352. if (next !== undefined) handleCommentSeek({ timestamp: next } as Comment);
  353. }, [visibleComments, currentTime, handleCommentSeek]);
  354. // Only main comments (not replies, not deleted) have annotations that should show on the video
  355. const visibleAnnotations = visibleComments
  356. .filter(c => !c.deleted)
  357. .flatMap(c =>
  358. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  359. );
  360. // Annotations for the compare video — independent per-video data
  361. const compareVisibleComments = compareComments.filter(c => !c.deleted && (showResolved || !c.resolved));
  362. const compareVisibleAnnotations = compareVisibleComments
  363. .filter(c => !c.deleted)
  364. .flatMap(c =>
  365. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  366. );
  367. if (loading) {
  368. return (
  369. <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  370. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  371. <div className="w-5 h-5 rounded-full animate-spin"
  372. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  373. <span className="text-sm">Loading review…</span>
  374. </div>
  375. </div>
  376. );
  377. }
  378. if (!asset) return null;
  379. return (
  380. <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
  381. {/* ── Top bar ──────────────────────────────────────────── */}
  382. <header className="h-12 flex items-center px-4 gap-3 shrink-0"
  383. style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
  384. <button
  385. onClick={() => router.push(`/projects/${asset.projectId}`)}
  386. className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
  387. style={{ color: 'var(--text-muted)' }}
  388. >
  389. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  390. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  391. </svg>
  392. <span className="hidden sm:inline">Back</span>
  393. </button>
  394. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  395. <div className="flex-1 min-w-0">
  396. <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
  397. </div>
  398. <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
  399. {asset.project?.name}
  400. </span>
  401. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  402. {/* Download */}
  403. <a
  404. href={`${API_BASE}/uploads/${asset.filePath}`}
  405. download={asset.filename}
  406. className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
  407. style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
  408. title="Download original video"
  409. >
  410. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  411. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
  412. </svg>
  413. <span className="hidden sm:inline">Download</span>
  414. </a>
  415. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  416. {/* Compare mode toggle */}
  417. <button
  418. onClick={() => {
  419. if (compareMode) {
  420. handleExitCompare();
  421. } else {
  422. setShowComparePicker(true);
  423. if (token && asset) {
  424. assetsApi.list(token, asset.projectId).then(({ assets }) => {
  425. setProjectAssets(assets.filter(a => a.id !== assetId && a.transcodeStatus === 'COMPLETED'));
  426. }).catch(() => {});
  427. }
  428. }
  429. }}
  430. className={`flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0 ${
  431. compareMode
  432. ? 'bg-indigo-600 text-white'
  433. : ''
  434. }`}
  435. style={!compareMode ? { color: '#818CF8', background: 'rgba(129,140,248,0.10)' } : {}}
  436. title="Side-by-side comparison"
  437. >
  438. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  439. <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
  440. </svg>
  441. <span className="hidden sm:inline">{compareMode ? 'Exit Compare' : 'Compare'}</span>
  442. </button>
  443. {/* Status selector */}
  444. <div className="relative shrink-0">
  445. <button
  446. onClick={() => setShowApproval(v => !v)}
  447. className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
  448. style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
  449. >
  450. <span className={`status-dot ${statusCfg.dotClass}`} />
  451. <span className="hidden sm:inline">{statusCfg.label}</span>
  452. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  453. <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
  454. </svg>
  455. </button>
  456. {showApproval && (
  457. <>
  458. <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
  459. <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
  460. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
  461. {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
  462. <button
  463. key={key}
  464. onClick={() => handleStatusUpdate(key)}
  465. disabled={updatingStatus}
  466. className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
  467. style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
  468. >
  469. <span className={`status-dot ${cfg.dotClass}`} />
  470. <span className="flex-1 text-left">{cfg.label}</span>
  471. {key === status && (
  472. <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
  473. <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
  474. </svg>
  475. )}
  476. </button>
  477. ))}
  478. </div>
  479. </>
  480. )}
  481. </div>
  482. </header>
  483. {/* ── Compare picker modal ─────────────────────────────────────────────── */}
  484. {showComparePicker && (
  485. <>
  486. <div className="fixed inset-0 z-50" onClick={() => setShowComparePicker(false)} />
  487. <div
  488. className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md"
  489. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-modal)' }}
  490. >
  491. <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  492. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Select video to compare</h2>
  493. <button onClick={() => setShowComparePicker(false)} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10"
  494. style={{ color: 'var(--text-muted)' }}>
  495. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  496. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  497. </svg>
  498. </button>
  499. </div>
  500. <div className="p-2 max-h-80 overflow-y-auto">
  501. {projectAssets.length === 0 ? (
  502. <p className="text-sm text-center py-8" style={{ color: 'var(--text-muted)' }}>
  503. No other completed videos in this project.
  504. </p>
  505. ) : (
  506. projectAssets.map(a => (
  507. <button
  508. key={a.id}
  509. onClick={() => handleCompareSelect(a)}
  510. className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors hover:bg-white/5"
  511. >
  512. {a.thumbnail ? (
  513. <img src={`${API_BASE}/uploads/${a.thumbnail}`} className="w-16 h-10 rounded-lg object-cover shrink-0" alt={a.title} />
  514. ) : (
  515. <div className="w-16 h-10 rounded-lg shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
  516. <svg className="w-5 h-5" style={{ color: 'rgba(255,255,255,0.2)' }} fill="currentColor" viewBox="0 0 24 24">
  517. <path d="M8 5v14l11-7z" />
  518. </svg>
  519. </div>
  520. )}
  521. <div className="flex-1 min-w-0">
  522. <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{a.title}</p>
  523. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  524. {a.duration ? `${Math.floor(a.duration / 60)}:${Math.floor(a.duration % 60).toString().padStart(2, '0')}` : '—'}
  525. {' · '}
  526. {a.filename}
  527. </p>
  528. </div>
  529. </button>
  530. ))
  531. )}
  532. </div>
  533. </div>
  534. </>
  535. )}
  536. {/* ── Body ───────────────────────────────────────────── */}
  537. {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
  538. <div
  539. className="flex flex-1 overflow-hidden"
  540. style={isPortrait
  541. ? { flexDirection: 'column', overflowY: 'auto' }
  542. : { flexDirection: 'row' }}
  543. >
  544. {/* Video area */}
  545. <div
  546. className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
  547. style={isPortrait
  548. ? { flex: 'none', width: '100%', minHeight: '45vh' }
  549. : { flex: 1, overflowY: 'auto' }}
  550. >
  551. {/* ── Side-by-side compare layout ───────────────────────── */}
  552. {compareMode ? (
  553. <div className="flex gap-2 w-full flex-1 min-h-0">
  554. {/* Main video + its comments */}
  555. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  556. {/* Annotation toggle */}
  557. <div className="flex items-center gap-2 mb-1 px-1">
  558. <button
  559. onClick={() => setShowMainAnnotations(v => !v)}
  560. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  561. style={showMainAnnotations
  562. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  563. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  564. title={showMainAnnotations ? 'Hide annotations' : 'Show annotations'}
  565. >
  566. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  567. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  568. </svg>
  569. Annot.
  570. </button>
  571. </div>
  572. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  573. {asset.title}
  574. </div>
  575. <div className="flex-1 min-h-0 flex flex-col gap-0">
  576. <VideoPlayer
  577. src={videoUrl}
  578. mimeType={asset.mimeType}
  579. fps={fps}
  580. comments={showMainAnnotations ? allComments : []}
  581. visibleAnnotations={showMainAnnotations ? visibleAnnotations : []}
  582. drawMode={drawMode}
  583. drawTool={drawTool}
  584. drawColor={drawColor}
  585. onDrawModeChange={setDrawMode}
  586. onDrawToolChange={setDrawTool}
  587. onDrawColorChange={setDrawColor}
  588. pendingStrokes={pendingStrokes}
  589. onStrokeComplete={handleStrokeComplete}
  590. onTimeUpdate={handleTimeUpdate}
  591. onCommentClick={handleCommentSeek}
  592. onPlayingChange={setPlaying}
  593. onTimelineSeek={handleTimeUpdate}
  594. externalCurrentTime={currentTime}
  595. externalPlaying={playing}
  596. videoRef={mainVideoRef}
  597. onPrevComment={handlePrevComment}
  598. onNextComment={handleNextComment}
  599. />
  600. {/* Comments below main video — full available height */}
  601. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  602. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  603. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  604. Comments
  605. </span>
  606. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  607. {visibleComments.length}
  608. </span>
  609. <span className="font-mono text-[11px] ml-auto" style={{ color: '#818CF8' }}>
  610. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  611. </span>
  612. </div>
  613. <div className="flex-1 overflow-y-auto scroll-area">
  614. {visibleComments.length === 0 ? (
  615. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  616. ) : (
  617. visibleComments.map(comment => (
  618. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  619. <Avatar name={comment.user?.name ?? 'U'} size="xs" />
  620. <div className="flex-1 min-w-0">
  621. <div className="flex items-center gap-1.5 mb-0.5">
  622. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  623. {comment.timestamp != null && (
  624. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  625. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  626. </span>
  627. )}
  628. </div>
  629. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  630. </div>
  631. </div>
  632. ))
  633. )}
  634. </div>
  635. </div>
  636. </div>
  637. </div>
  638. {/* Compare video + its comments — only show when durations match */}
  639. {compareAsset && !compareMismatch && (
  640. <div className="flex-1 min-w-0 flex flex-col gap-0 min-h-0">
  641. {/* Annotation toggle */}
  642. <div className="flex items-center gap-2 mb-1 px-1">
  643. <button
  644. onClick={() => setShowCompareAnnotations(v => !v)}
  645. className="flex items-center gap-1.5 text-[11px] px-2 py-1 rounded-md transition-colors"
  646. style={showCompareAnnotations
  647. ? { background: 'rgba(99,102,241,0.15)', color: '#818CF8' }
  648. : { background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}
  649. title={showCompareAnnotations ? 'Hide annotations' : 'Show annotations'}
  650. >
  651. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  652. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  653. </svg>
  654. Annot.
  655. </button>
  656. </div>
  657. <div className="text-xs mb-1 px-1 truncate" style={{ color: 'rgba(255,255,255,0.5)' }}>
  658. {compareAsset.title}
  659. </div>
  660. <div className="flex-1 min-h-0 flex flex-col gap-0">
  661. <VideoPlayer
  662. src={compareAsset.hlsPath ? `${API_BASE}/uploads${compareAsset.hlsPath}` : `${API_BASE}/uploads/${compareAsset.filePath}`}
  663. mimeType={compareAsset.mimeType}
  664. fps={compareAsset.fps ?? 30}
  665. comments={showCompareAnnotations ? compareComments : []}
  666. visibleAnnotations={showCompareAnnotations ? compareVisibleAnnotations : []}
  667. drawMode={false}
  668. drawTool={drawTool}
  669. drawColor={drawColor}
  670. onDrawModeChange={() => {}}
  671. onDrawToolChange={() => {}}
  672. onDrawColorChange={() => {}}
  673. pendingStrokes={[]}
  674. onStrokeComplete={() => {}}
  675. onTimeUpdate={() => {}}
  676. onCommentClick={() => {}}
  677. isComparePlayer={true}
  678. externalCurrentTime={currentTime}
  679. externalPlaying={playing}
  680. />
  681. {/* Comments below compare video — full available height */}
  682. <div className="mt-2 rounded-xl flex-1 min-h-0 flex flex-col overflow-hidden" style={{ background: 'rgba(10,11,20,0.80)', border: '1px solid rgba(255,255,255,0.06)' }}>
  683. <div className="px-3 py-2 shrink-0 flex items-center gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  684. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  685. Comments
  686. </span>
  687. <span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  688. {compareVisibleComments.length}
  689. </span>
  690. </div>
  691. <div className="flex-1 overflow-y-auto scroll-area">
  692. {compareVisibleComments.length === 0 ? (
  693. <p className="text-xs text-center py-4" style={{ color: 'var(--text-muted)' }}>No comments</p>
  694. ) : (
  695. compareVisibleComments.map(comment => (
  696. <div key={comment.id} className="px-3 py-2.5 flex items-start gap-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
  697. <Avatar name={comment.user?.name ?? 'U'} size="xs" />
  698. <div className="flex-1 min-w-0">
  699. <div className="flex items-center gap-1.5 mb-0.5">
  700. <span className="text-[11px] font-medium" style={{ color: 'var(--text)' }}>{comment.user?.name ?? 'Unknown'}</span>
  701. {comment.timestamp != null && (
  702. <span className="text-[10px] font-mono px-1 rounded" style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}>
  703. {formatTimecode(comment.timestamp, fps, asset?.duration ?? 0)}
  704. </span>
  705. )}
  706. </div>
  707. <p className="text-[11px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>{comment.content}</p>
  708. </div>
  709. </div>
  710. ))
  711. )}
  712. </div>
  713. </div>
  714. </div>
  715. </div>
  716. )}
  717. </div>
  718. ) : (
  719. /* ── Normal single-video layout ─────────────────────────── */
  720. <VideoPlayer
  721. src={videoUrl}
  722. mimeType={asset.mimeType}
  723. fps={fps}
  724. comments={allComments}
  725. visibleAnnotations={visibleAnnotations}
  726. drawMode={drawMode}
  727. drawTool={drawTool}
  728. drawColor={drawColor}
  729. onDrawModeChange={setDrawMode}
  730. onDrawToolChange={setDrawTool}
  731. onDrawColorChange={setDrawColor}
  732. pendingStrokes={pendingStrokes}
  733. onStrokeComplete={handleStrokeComplete}
  734. onTimeUpdate={handleTimeUpdate}
  735. onCommentClick={handleCommentSeek}
  736. onPlayingChange={setPlaying}
  737. videoRef={mainVideoRef}
  738. onPrevComment={handlePrevComment}
  739. onNextComment={handleNextComment}
  740. />
  741. )}
  742. {/* ── Compare mismatch warning ─────────────────────────── */}
  743. {compareMode && compareMismatch && (
  744. <div className="rounded-xl px-4 py-3 text-xs flex items-center gap-3"
  745. style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.25)', color: '#FCD34D' }}>
  746. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  747. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  748. </svg>
  749. <span className="flex-1">{compareMismatch}</span>
  750. <button
  751. onClick={handleExitCompare}
  752. className="shrink-0 px-2 py-1 rounded-md transition-colors"
  753. style={{ background: 'rgba(251,191,36,0.15)', color: '#FCD34D' }}
  754. >
  755. Cancel
  756. </button>
  757. </div>
  758. )}
  759. {/* Transcode status overlay — shown when video is not ready */}
  760. {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
  761. <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
  762. style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
  763. {transcodeCfg.spinner ? (
  764. <div className="w-8 h-8 rounded-full animate-spin shrink-0"
  765. style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
  766. ) : asset.transcodeStatus === 'FAILED' ? (
  767. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  768. style={{ background: 'rgba(248,113,113,0.15)' }}>
  769. <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  770. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  771. </svg>
  772. </div>
  773. ) : (
  774. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  775. style={{ background: 'rgba(251,191,36,0.15)' }}>
  776. <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  777. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  778. </svg>
  779. </div>
  780. )}
  781. <div className="flex-1 min-w-0">
  782. <div className="flex items-center gap-2 mb-1">
  783. <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
  784. {transcodeCfg.label}
  785. </span>
  786. {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
  787. <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
  788. {asset.transcodeProgress}%
  789. </span>
  790. )}
  791. </div>
  792. {asset.transcodeStatus === 'PROCESSING' && (
  793. <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  794. <div
  795. className="h-full rounded-full transition-all duration-500"
  796. style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
  797. />
  798. </div>
  799. )}
  800. {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
  801. <p className="text-xs mt-1" style={{ color: '#F87171' }}>
  802. {asset.transcodeError}
  803. </p>
  804. )}
  805. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  806. <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
  807. {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
  808. </p>
  809. )}
  810. {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
  811. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  812. Converting from {asset.codec.toUpperCase()} → H.264/AAC
  813. </p>
  814. )}
  815. {asset.transcodeStatus === 'UPLOADING' && (
  816. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  817. Video uploaded — queued for processing
  818. </p>
  819. )}
  820. </div>
  821. </div>
  822. )}
  823. {/* Keyboard shortcuts */}
  824. {!compareMode && (
  825. <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" style={{ color: 'var(--text-subtle)' }}>
  826. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
  827. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> ±1 frame <kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>⇧→</kbd> ±1s</span>
  828. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
  829. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
  830. <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
  831. </div>
  832. )}
  833. </div>
  834. {/* Resize handle — only shown in landscape, hidden in compare mode */}
  835. {!isPortrait && !compareMode && (
  836. <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
  837. )}
  838. {/* ── Comment panel — hidden in compare mode (comments are below each video) ── */}
  839. {!compareMode && (
  840. <div
  841. ref={panelRef}
  842. className="flex flex-col overflow-hidden shrink-0"
  843. style={isPortrait
  844. ? {
  845. flex: 1,
  846. width: '100%',
  847. minHeight: '55vh',
  848. background: 'rgba(10,11,20,0.98)',
  849. borderTop: '1px solid rgba(255,255,255,0.06)',
  850. }
  851. : {
  852. width: panelWidth,
  853. background: 'rgba(10,11,20,0.98)',
  854. borderLeft: '1px solid rgba(255,255,255,0.06)',
  855. }}
  856. >
  857. {/* Panel header */}
  858. <div className="px-3 sm:px-4 py-2.5 sm:py-3 flex items-center justify-between shrink-0"
  859. style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  860. <div className="flex items-center gap-2">
  861. <h2 className="text-[13px] sm:text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
  862. <span className="text-xs px-1.5 py-0.5 rounded-full"
  863. style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  864. {comments.length}
  865. </span>
  866. </div>
  867. <div className="flex items-center gap-2">
  868. <span className="font-mono text-[11px] sm:text-xs hidden sm:inline" style={{ color: '#818CF8' }}>
  869. {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
  870. </span>
  871. <button
  872. onClick={() => setShowResolved(v => !v)}
  873. className={`text-[11px] px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
  874. style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
  875. >
  876. {showResolved ? 'Hide resolved' : 'Show resolved'}
  877. </button>
  878. {compareMode && (
  879. <span className="text-[11px] px-2 py-0.5 rounded-md" style={{ background: 'rgba(99,102,241,0.15)', color: '#818CF8' }}>
  880. Compare mode
  881. </span>
  882. )}
  883. </div>
  884. </div>
  885. {/* Drawing mode banner */}
  886. {drawMode && (
  887. <div className="px-4 py-2 shrink-0 flex items-center gap-2"
  888. style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
  889. <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  890. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  891. </svg>
  892. <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
  893. {annotatingComment
  894. ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
  895. : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
  896. </span>
  897. <div className="flex items-center gap-1.5">
  898. <button
  899. onClick={handleUndoAnnotations}
  900. className="text-xs px-2 py-0.5 rounded transition-colors"
  901. style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
  902. >
  903. Undo all
  904. </button>
  905. <button
  906. onClick={handleSaveAnnotations}
  907. disabled={submitting || pendingStrokes.length === 0}
  908. className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
  909. style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
  910. >
  911. {submitting ? 'Saving…' : 'Save'}
  912. </button>
  913. </div>
  914. </div>
  915. )}
  916. {/* Comment list */}
  917. <div className="flex-1 overflow-y-auto scroll-area">
  918. {visibleComments.length === 0 ? (
  919. <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
  920. <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
  921. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  922. <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  923. <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
  924. </svg>
  925. </div>
  926. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
  927. <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  928. Add a comment below or click <strong>Add annotation</strong> on an existing comment
  929. </p>
  930. </div>
  931. ) : (
  932. <div>
  933. {visibleComments.map(comment => (
  934. <CommentItem
  935. key={comment.id}
  936. comment={comment}
  937. currentUserId={user?.id ?? ''}
  938. fps={fps}
  939. duration={asset?.duration ?? 0}
  940. canComment={canComment}
  941. isProjectAdmin={isProjectAdmin}
  942. isProjectOwner={isProjectOwner ?? false}
  943. onTimestampClick={handleCommentSeek}
  944. onReply={() => { setReplyTo(comment); }}
  945. onResolve={(action) => handleResolve(comment.id, action)}
  946. onRequestResolve={() => handleRequestResolve(comment.id)}
  947. onDeleteSelf={() => handleDeleteComment(comment.id)}
  948. onDelete={(id) => handleDeleteComment(id)}
  949. onAddAnnotation={() => handleAddAnnotationClick(comment)}
  950. onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
  951. onRestore={handleRestoreComment}
  952. />
  953. ))}
  954. </div>
  955. )}
  956. </div>
  957. {/* New comment / reply input */}
  958. <div className="shrink-0 p-4"
  959. style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
  960. {replyTo && (
  961. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  962. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  963. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  964. </svg>
  965. Replying to {replyTo.user?.name}
  966. <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
  967. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  968. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  969. </svg>
  970. </button>
  971. </div>
  972. )}
  973. {/* Pending strokes indicator */}
  974. {pendingStrokes.length > 0 && (
  975. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
  976. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  977. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  978. </svg>
  979. {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
  980. {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
  981. <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
  982. </div>
  983. )}
  984. <form
  985. onSubmit={e => {
  986. e.preventDefault();
  987. if (newComment.trim() || pendingStrokes.length > 0) {
  988. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  989. }
  990. }}
  991. className="flex gap-2"
  992. >
  993. <Avatar name={user?.name ?? 'U'} size="sm" />
  994. <div className="flex-1 flex gap-2">
  995. <textarea
  996. className="input flex-1"
  997. value={compareMode ? '' : newComment}
  998. onChange={e => setNewComment(e.target.value)}
  999. placeholder={compareMode ? 'Comments disabled in compare mode' : replyTo ? 'Write a reply…' : 'Add a comment…'}
  1000. disabled={compareMode}
  1001. readOnly={compareMode}
  1002. rows={1}
  1003. style={{ resize: 'none', overflow: 'hidden' }}
  1004. onKeyDown={e => {
  1005. if (e.key === 'Enter' && !e.shiftKey) {
  1006. e.preventDefault();
  1007. if (newComment.trim() || pendingStrokes.length > 0) {
  1008. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  1009. }
  1010. }
  1011. }}
  1012. />
  1013. <button
  1014. type="submit"
  1015. disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
  1016. className="btn btn-primary btn-sm px-3"
  1017. >
  1018. {submitting ? (
  1019. <div className="w-3.5 h-3.5 rounded-full animate-spin"
  1020. style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
  1021. ) : (
  1022. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1023. <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
  1024. </svg>
  1025. )}
  1026. </button>
  1027. </div>
  1028. </form>
  1029. </div>
  1030. </div>
  1031. )}
  1032. </div>
  1033. </div>
  1034. );
  1035. }
  1036. // ── CommentItem ─────────────────────────────────────────────────────────────
  1037. function CommentItem({
  1038. comment,
  1039. currentUserId,
  1040. fps,
  1041. duration,
  1042. canComment,
  1043. isProjectAdmin,
  1044. isProjectOwner,
  1045. onTimestampClick,
  1046. onReply,
  1047. onResolve,
  1048. onRequestResolve,
  1049. onDeleteSelf,
  1050. onDelete,
  1051. onAddAnnotation,
  1052. onDeleteAnnotation,
  1053. onRestore,
  1054. }: {
  1055. comment: Comment;
  1056. currentUserId: string;
  1057. fps: number;
  1058. duration: number;
  1059. canComment: boolean | undefined;
  1060. isProjectAdmin: boolean;
  1061. isProjectOwner: boolean;
  1062. onTimestampClick: (c: Comment) => void;
  1063. onReply: () => void;
  1064. onResolve: (action: 'approve' | 'reject') => void;
  1065. onRequestResolve: () => void;
  1066. onDeleteSelf: () => void;
  1067. onDelete: (id: string) => void;
  1068. onAddAnnotation: () => void;
  1069. onDeleteAnnotation: (annotations: AnnotationData[]) => void;
  1070. onRestore: (id: string) => void;
  1071. }) {
  1072. const isOwner = comment.userId === currentUserId;
  1073. const isCommentAuthor = comment.userId === currentUserId;
  1074. const name = comment.user?.name ?? 'Unknown';
  1075. const isReply = !!comment.parentId;
  1076. const annotations = comment.annotations ?? [];
  1077. const canAddMore = annotations.length < MAX_ANNOTATIONS;
  1078. const isDeleted = !!comment.deleted;
  1079. const canRestore = !isDeleted && (isProjectOwner || isProjectAdmin);
  1080. // Resolve state machine
  1081. const isResolved = comment.resolveStatus === 'RESOLVED';
  1082. const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
  1083. const canApprove = isCommentAuthor || isProjectAdmin;
  1084. const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
  1085. const canReopen = isResolved && canApprove;
  1086. return (
  1087. <div
  1088. className="p-4 animate-fade-in"
  1089. style={{
  1090. opacity: isDeleted ? 0.45 : isResolved ? 0.55 : 1,
  1091. paddingLeft: isReply ? '2.5rem' : undefined,
  1092. borderLeft: isDeleted ? '2px solid rgba(239,68,68,0.3)' : undefined,
  1093. }}
  1094. >
  1095. <div className="flex gap-2.5">
  1096. <Avatar name={name} size="sm" />
  1097. <div className="flex-1 min-w-0">
  1098. {/* Meta row */}
  1099. <div className="flex items-center gap-2 mb-1 flex-wrap">
  1100. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
  1101. {comment.timestamp != null && (
  1102. <button
  1103. onClick={() => onTimestampClick(comment)}
  1104. className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
  1105. style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
  1106. >
  1107. {formatTimecode(comment.timestamp, fps, duration)}
  1108. </button>
  1109. )}
  1110. {isPending && (
  1111. <span className="text-xs px-1.5 py-0.5 rounded"
  1112. style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
  1113. Pending approval
  1114. </span>
  1115. )}
  1116. {isResolved && (
  1117. <span className="text-xs px-1.5 py-0.5 rounded"
  1118. style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
  1119. Approved
  1120. </span>
  1121. )}
  1122. {isResolved && comment.resolvedBy && (
  1123. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1124. by {comment.resolvedBy.name}
  1125. </span>
  1126. )}
  1127. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1128. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1129. </span>
  1130. </div>
  1131. {/* Annotation preview badges */}
  1132. {annotations.length > 0 && (
  1133. <div className="flex flex-wrap gap-1 mb-2">
  1134. {annotations.map((ann, i) => (
  1135. <div
  1136. key={i}
  1137. className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
  1138. style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
  1139. >
  1140. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1141. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1142. </svg>
  1143. {ann.type}
  1144. {isOwner && (
  1145. <button
  1146. onClick={() => {
  1147. const remaining = annotations.filter((_, j) => j !== i);
  1148. onDeleteAnnotation(remaining);
  1149. }}
  1150. className="ml-0.5 hover:opacity-70 transition-opacity"
  1151. title="Delete this annotation"
  1152. >
  1153. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1154. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1155. </svg>
  1156. </button>
  1157. )}
  1158. </div>
  1159. ))}
  1160. </div>
  1161. )}
  1162. {/* Content */}
  1163. <p className="text-[13px] sm:text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
  1164. {comment.content}
  1165. </p>
  1166. {/* Actions */}
  1167. <div className="flex items-center gap-1">
  1168. {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
  1169. {isDeleted && (isProjectOwner || isProjectAdmin) && (
  1170. <button
  1171. onClick={() => onRestore(comment.id)}
  1172. className="text-xs px-2 py-1 rounded-md transition-colors"
  1173. style={{ color: '#86EFAC', background: 'rgba(34,197,94,0.10)' }}
  1174. title="Restore this comment"
  1175. >
  1176. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1177. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1178. </svg>
  1179. Restore
  1180. </button>
  1181. )}
  1182. {!isReply && !isDeleted && (
  1183. <button
  1184. onClick={onAddAnnotation}
  1185. disabled={!canAddMore}
  1186. className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
  1187. style={{ color: '#818CF8' }}
  1188. title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
  1189. >
  1190. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1191. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  1192. </svg>
  1193. </button>
  1194. )}
  1195. {!isReply && !isDeleted && (
  1196. <button
  1197. onClick={onReply}
  1198. className="text-xs px-2 py-1 rounded-md transition-colors"
  1199. style={{ color: 'var(--text-muted)' }}
  1200. title="Reply"
  1201. >
  1202. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1203. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1204. </svg>
  1205. </button>
  1206. )}
  1207. {!isReply && (
  1208. <button
  1209. onClick={onReply}
  1210. className="text-xs px-2 py-1 rounded-md transition-colors"
  1211. style={{ color: 'var(--text-muted)' }}
  1212. title="Reply"
  1213. >
  1214. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1215. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  1216. </svg>
  1217. </button>
  1218. )}
  1219. {/* Resolve / approval workflow buttons */}
  1220. {!isReply && !isDeleted && !isResolved && !isPending && (
  1221. <>
  1222. {canRequest ? (
  1223. <button
  1224. onClick={onRequestResolve}
  1225. className="text-xs px-2 py-1 rounded-md transition-colors"
  1226. style={{ color: '#6366F1' }}
  1227. title="Request resolve approval"
  1228. >
  1229. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1230. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1231. </svg>
  1232. Request resolve
  1233. </button>
  1234. ) : (
  1235. <span
  1236. className="text-xs px-2 py-1 opacity-30"
  1237. style={{ color: '#6366F1' }}
  1238. title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
  1239. >
  1240. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1241. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1242. </svg>
  1243. Request resolve
  1244. </span>
  1245. )}
  1246. </>
  1247. )}
  1248. {isPending && canApprove && !isReply && !isDeleted && (
  1249. <>
  1250. <button
  1251. onClick={() => onResolve('approve')}
  1252. className="text-xs px-2 py-1 rounded-md transition-colors"
  1253. style={{ color: '#86EFAC' }}
  1254. title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
  1255. >
  1256. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1257. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1258. </svg>
  1259. Approve
  1260. </button>
  1261. <button
  1262. onClick={() => onResolve('reject')}
  1263. className="text-xs px-2 py-1 rounded-md transition-colors"
  1264. style={{ color: '#FCA5A5' }}
  1265. title="Reject resolve request"
  1266. >
  1267. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1268. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1269. </svg>
  1270. Reject
  1271. </button>
  1272. </>
  1273. )}
  1274. {isPending && !canApprove && !isReply && !isDeleted && (
  1275. <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
  1276. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1277. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  1278. </svg>
  1279. Awaiting approval
  1280. </span>
  1281. )}
  1282. {canReopen && !isReply && !isDeleted && (
  1283. <button
  1284. onClick={() => onResolve('reject')}
  1285. className="text-xs px-2 py-1 rounded-md transition-colors"
  1286. style={{ color: '#86EFAC' }}
  1287. title="Reopen comment"
  1288. >
  1289. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1290. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  1291. </svg>
  1292. Reopen
  1293. </button>
  1294. )}
  1295. {isOwner && !isDeleted && (
  1296. <button
  1297. onClick={onDeleteSelf}
  1298. className="text-xs px-2 py-1 rounded-md transition-colors"
  1299. style={{ color: 'var(--text-subtle)' }}
  1300. title="Hide comment"
  1301. >
  1302. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1303. <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
  1304. </svg>
  1305. </button>
  1306. )}
  1307. </div>
  1308. {/* Replies */}
  1309. {comment.replies && comment.replies.length > 0 && (
  1310. <div className="mt-3 space-y-3">
  1311. {comment.replies.map(reply => (
  1312. <ReplyItem
  1313. key={reply.id}
  1314. comment={reply}
  1315. isOwner={reply.userId === currentUserId}
  1316. onDelete={() => onDelete(reply.id)}
  1317. />
  1318. ))}
  1319. </div>
  1320. )}
  1321. </div>
  1322. </div>
  1323. </div>
  1324. );
  1325. }
  1326. // ── ReplyItem ──────────────────────────────────────────────────────────────
  1327. // Replies have no resolve, no annotation, no timestamp — just content + delete
  1328. function ReplyItem({
  1329. comment,
  1330. isOwner,
  1331. onDelete,
  1332. }: {
  1333. comment: Comment;
  1334. isOwner: boolean;
  1335. onDelete: (id: string) => void;
  1336. }) {
  1337. return (
  1338. <div className="flex gap-2.5 animate-fade-in">
  1339. <Avatar name={comment.user?.name ?? 'U'} size="sm" />
  1340. <div className="flex-1 min-w-0">
  1341. <div className="flex items-center gap-2 mb-0.5">
  1342. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  1343. {comment.user?.name ?? 'Unknown'}
  1344. </span>
  1345. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  1346. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  1347. </span>
  1348. </div>
  1349. <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  1350. {comment.content}
  1351. </p>
  1352. {isOwner && (
  1353. <button
  1354. onClick={() => onDelete(comment.id)}
  1355. className="text-xs mt-1 transition-colors"
  1356. style={{ color: 'var(--text-subtle)' }}
  1357. title="Delete reply"
  1358. >
  1359. Delete
  1360. </button>
  1361. )}
  1362. </div>
  1363. </div>
  1364. );
  1365. }